從 Day 5 到 Day 8,我們花了不少篇幅在討論「如何準確抓到畫面上的元素」。今天我們要來看看 Playwright Test 提供了哪些好用的控制項。
這篇文章分成兩個部分:前半段會跟大家聊聊怎麼把測試更有條理地組織起來(包含 describe 與 hooks 的用法),後半段則會探討前面幾天實作時,有碰到的 Test timeout of 30000ms exceeded 錯誤。
describe 把相關的測試分成一組先回頭看看我們在 Day 6 寫的 scratch.spec.ts,當時裡面只有一支測試。現在假設我們想再加一支「進入頁面後會顯示商品列表」,程式碼可能會變成這樣:
test('進入頁面後會顯示商品列表', async ({ page }) => {
const loginPage = new LoginPage(page);
const productsPage = new ProductsPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
await productsPage.goto();
// ...後續驗證
});
test('搜尋商品後,列表只顯示符合的結果', async ({ page }) => {
const loginPage = new LoginPage(page);
const productsPage = new ProductsPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
await productsPage.goto();
// ...後續驗證
});
仔細看前面五行幾乎完全一樣。這就是 Day 5 提過的 beforeEach 可以派上用場的地方,今天我們要把它跟 describe 搭配在一起使用。
test.describe() 的主要作用是把相關的測試包成一組,這麼做有兩個好處:
現在,我們把這兩支測試包起來,並把共通的前置動作抽到 beforeEach 裡面,scratch.spec.ts 就會變成這樣:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductsPage } from '../pages/ProductsPage';
test.describe('商品列表', () => {
let productsPage: ProductsPage;
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('demo', 'demo');
productsPage = new ProductsPage(page);
await productsPage.goto();
});
test('進入頁面後會顯示商品列表', async () => {
await expect(productsPage.resultCount).toBeVisible();
await expect(productsPage.createButton).toBeVisible();
});
test('搜尋商品後,列表只顯示符合的結果', async () => {
const totalBeforeSearch = await productsPage.resultCount.innerText();
await productsPage.search('beach');
await expect(productsPage.resultCount).not.toHaveText(totalBeforeSearch);
await expect(productsPage.productCard('Beach Gazebo')).toBeVisible();
});
});
這裡有兩個小細節需要注意:
productsPage 宣告在 describe 的最外層:因為我們要在 beforeEach 裡面建立它,然後在每支測試裡面使用它,所以必須放在兩者都看得到的位置。這是 POM 搭配 beforeEach 時的經典寫法。{ page }:因為在beforeEach裡面已經取得ProductsPage,後續測試的操作全部透過 productsPage 進行,所以測試不需要直接去碰 page 了。Playwright Test 提供了四個實用的 hooks,它們是兩兩成對的:
| Hook | 執行時機 |
|---|---|
beforeAll |
整組測試開始前,只跑一次 |
beforeEach |
每一支測試開始前都會跑 |
afterEach |
每一支測試結束後都會跑 |
afterAll |
整組測試結束後,只跑一次 |
文字描述可能還是有點抽象,我們直接在上面那份 scratch.spec.ts 裡面各塞一行 console.log,實際跑一次看看會印出什麼:
test.describe('商品列表', () => {
let productsPage: ProductsPage;
test.beforeAll(async () => {
console.log('beforeAll:整組測試開始前跑一次');
});
test.beforeEach(async ({ page }) => {
console.log('beforeEach:每支測試前登入並進入商品列表');
// ...略
});
test.afterEach(async () => {
console.log('afterEach:每支測試後跑一次');
});
test.afterAll(async () => {
console.log('afterAll:整組測試結束後跑一次');
});
test('進入頁面後會顯示商品列表', async () => {
console.log('test:進入頁面後會顯示商品列表');
// ...略
});
test('搜尋商品後,列表只顯示符合的結果', async () => {
console.log('test:搜尋商品後,列表只顯示符合的結果');
// ...略
});
});
輸出的結果會長這樣:
beforeAll:整組測試開始前跑一次
beforeEach:每支測試前登入並進入商品列表
test:進入頁面後會顯示商品列表
afterEach:每支測試後跑一次
✓ 1 商品列表 › 進入頁面後會顯示商品列表 (7.8s)
beforeEach:每支測試前登入並進入商品列表
test:搜尋商品後,列表只顯示符合的結果
afterEach:每支測試後跑一次
afterAll:整組測試結束後跑一次
✓ 2 商品列表 › 搜尋商品後,列表只顯示符合的結果 (7.2s)
這樣順序就很清楚了!流程就是:beforeAll → (beforeEach → 測試本身 → afterEach)重複執行 N 次 → 最後是 afterAll。
實務上,這四個 hooks 通常是這樣分工:beforeEach 用來準備每支測試都需要的前置狀態(例如我們剛才的登入動作),afterEach 用來清理這支測試留下來的測試資料;而 beforeAll 跟 afterAll 則適合放那些「跑一次就好」、且成本比較高的準備或清理工作。
這裡要提醒大家一個初學者很容易碰到的問題:beforeAll 裡面是拿不到 page 的。如果你嘗試這樣寫:
test.beforeAll(async ({ page }) => { // ❌ 會直接報錯
await page.goto('/');
});
Playwright 會毫不留情地告訴你:
Error: "context" and "page" fixtures are not supported in "beforeAll"
since they are created on a per-test basis.
這裡大家可能以為是因為「測試還沒開始」所以才會報錯。但beforeEach 也是在每支測試開始前執行,如果用這個理由解釋,那 beforeEach 應該也拿不到 page 才對。
真正的關鍵在錯誤訊息裡那句 created on a per-test basis,page 是綁定在單一一支測試上的 fixture。Playwright 每跑一支測試,就會開一個全新、乾淨的分頁給它專用,測試一結束就丟掉。beforeEach 每次執行都明確對應到「接下來要跑的那一支測試」,所以 Playwright 可以在執行它之前,先把那支測試專屬的 page 準備好。
但是,beforeAll 是在整組測試開始前跑一次,它不屬於任何一支特定的測試。如果它也要拿到 page,那這個 page 到底該算誰的呢?因為沒有答案,所以 Playwright 乾脆不提供。因此只要是需要操作頁面的前置動作,要放在 beforeEach 裡面跑,而不是beforeAll裡面。
首先我們要有一個認知:Playwright 預設就會幫你等待元素準備好才進行操作。
在Day 5 我們有提到locator 是「惰性求值」——當你寫下 page.getByRole('button', { name: 'Sign in' }) 的當下,什麼事都不會發生,要等到你加上 .click() 等動作時,它才會真正去頁面上找目標。而且Playwright 在找的時候不是找一次沒找到就放棄,它會反覆檢查一系列的可操作性條件 (Actionability checks):這個元素存在嗎?可見嗎?位置穩定下來了嗎?有沒有被其他元素蓋住?是 enabled 的嗎?必須全部條件都通過,它才會真的點下去。
但問題來了:如果這些條件永遠不會成立呢?比如說,某次改版把按鈕整個拿掉了,你的 locator 從頭到尾都找不到對應的元素。如果「等待」這件事沒有上限,Playwright 就會無止盡地等下去。測試既不會失敗,也不會結束。想像一下,CI 裡的 job 就這樣卡在那裡,後面排隊的測試全部沒機會執行,你也得不到任何錯誤訊息去追查原因。
timeout,它是「等待」的上限時間。等到達上限還是等不到,系統就會放棄並回報錯誤。有了它,一支測試不管最後是等到條件成立而順利通過,還是等到上限而超時失敗,都保證會在有限時間內結束,並給你一個明確的結果。這正是自動化測試能放進 CI 流程、能被信任的重要前提。沒有 timeout的話,隨時可能因為一支測試卡死而導致整批測試癱瘓。
Playwright 的 timeout 並不是只有一種,而是有好多層各自獨立設定。
| 層級 | 設定位置 | 預設值 |
|---|---|---|
| 整場測試 | globalTimeout |
0(不限) |
| 單支測試 | timeout |
30 秒 |
| 單一動作 | use.actionTimeout |
0(不限) |
| 導航 | use.navigationTimeout |
0(不限) |
| 斷言 | expect.timeout |
5 秒 |
| 啟動練習專案 | webServer.timeout |
60 秒 |
如果寫在設定檔裡面,大概會長這樣:
// playwright.config.ts
export default defineConfig({
timeout: 30 * 1000, // 單支測試的上限
expect: {
timeout: 5 * 1000, // 每個斷言的上限
},
use: {
baseURL: 'http://localhost:8000',
actionTimeout: 10 * 1000, // 每個動作的上限
navigationTimeout: 30 * 1000, // 每次導航的上限
},
});
這張表顯示的層級及預設值雖然是各自的timeout時間,但實際上他們是共用同一個額度,例如:timeout(單支測試的 30 秒)是最外層的總額度,測試裡的每一個動作、每一個斷言,扣的都是同一個額度,並不會因為你寫了新的動作就額外多給你時間。
舉個例子,如果一支測試裡有五個各等 5 秒才失敗的斷言,光是這樣就用掉了 25 秒,剩下 5 秒可能連最後一步都還沒跑完——這時候你看到的錯誤訊息會是 test timeout,而不是斷言失敗,但真正的問題其實是在前面那幾個慢慢等的斷言。actionTimeout 和 navigationTimeout 也是同樣的邏輯,它們只是在規定「這一步最多能花多少時間」,並不是另外有新的時間額度。
可以不設定嗎? 大部分情況是不會有問題的,因為Playwright 內建的預設值(單支測試 30 秒、斷言 5 秒)對多數專案來說已經很夠用了,所以我們通常不太需要去動這些設定。真正容易出狀況的,反而是內層設定被留在「不限」(例如 actionTimeout 和 navigationTimeout 預設都是 0),又剛好卡在一個永遠不會成立的操作上:這時候這一步就會不斷重試,直到把最外層 30 秒的總額度跑完。最後你看到的是語意不明的 test timeout,而不是能夠直接指出問題點的錯誤訊息。
如果設得太長、或設得不好呢? 把 timeout 拉長並不會讓你的測試變得穩定,它只會讓失敗的回饋變慢:一支原本 5 秒就該失敗的測試,如果你把 timeout 調到 60 秒,代價就是你要多等 55 秒才看得到同一個錯誤。當測試數量一多,所有測試的執行時間就會被拉長。
另一個迷思是,調大 timeout 常被當成「讓測試變綠」的偷吃步,但這反而掩蓋了測試失敗的根本原因(例如 locator 寫錯、或者等到了不該等的東西)。這次測試看似過了,但下次當環境變慢、或是 CI 機器負載較重時,測試一樣會不過,所以遇到 timeout 的時候,第一步該做的應該是找出卡在哪一步、為什麼會卡住,而不是直接就把數字調大。
怎麼看懂 timeout 的訊息並抓出錯誤?
舉個 Day 5 提過的 MUI 坑當例子,如果把 spinbutton 誤寫成 textbox:
await page.getByRole('textbox', { name: 'Width' }).fill('30');
超時後你會得到這段錯誤訊息:
Error: locator.fill: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByRole('textbox', { name: 'Width' })
Call log這邊會寫它在等一個 name 是 Width 的 textbox 出現。回頭去看程式碼就會發現問題是 locator 寫錯了,而不是網站太慢。
有時候 Call log 會更長,像是「元素被蓋住」或「處於 disabled 狀態」,都會逐項列出。所以,遇到 timeout 先去讀 Call log,可以讓你知道他timeout的原因。
我們可以由大到小分成四個層級來設定:
1. 全域設定:直接寫在 playwright.config.ts,這會套用到所有的測試(就像上面示範的那段)。
2. 整組測試:使用 test.describe.configure()。
test.describe('比較慢的一組測試', () => {
test.describe.configure({ timeout: 60 * 1000 });
// 這組裡的每支測試都會變成 60 秒的上限
});
3. 單支測試:使用 test.setTimeout(),或者直接用 test.slow() 讓時間放寬成三倍。
test('會跑很久的測試', async ({ page }) => {
test.setTimeout(60 * 1000);
// ...
});
test('稍微慢一點的測試', async ({ page }) => {
test.slow(); // 直接獲得三倍的時間
// ...
});
4. 單一斷言:直接在 matcher 帶入 timeout 參數。
// 這個報表要等後端算完,所以特別給它久一點的時間
await expect(page.getByTestId('report-total')).toBeVisible({ timeout: 15000 });
這裡要注意一下,test.slow() 是「當下生效的 timeout 的三倍」,而不是「設定檔預設值的三倍」。例如在一個 test.describe.configure({ timeout: 20000 }) 的區塊底下呼叫 test.slow(),印出來的 timeout 會是 60000(也就是 20000 的三倍),而不是 90000。所以如果你已經在 describe 層級調整過設定了,slow()採用的基準是你調整過後的數值。
waitForTimeout?我們在 Day 3 寫的那支驗證 Context 隔離性的 browserTest.ts 裡面加了這樣一行:
await pageA.waitForTimeout(5000);
那時候的目的是「讓畫面停在那裡五秒,好讓我們用肉眼看清楚兩個分頁的差異」,但這只是為了要暫停畫面示範,實際測試上不太會這樣用。
如果是要用這個來「等待某個狀態出現」,比如按下儲存後等後端處理完、或是等某段文字跑出來,這個用法其實並沒有對症下藥。因為waitForTimeout 等的是絕對的時間,而不是狀態。它不知道你要等的東西什麼時候會準備好,它就像定時器一樣照著設定的秒數等;就算你要等的狀態已經準備好了它也不會提早結束,沒準備好的話它也不會多等。
遇到這種情況,正確的做法是使用斷言。expect 本身就會自動重試直到條件成立為止(重試的上限時間就是前面說的 expect.timeout),完全不需要自己手動插一段固定的等待時間進去:
// ❌ 千萬不要這樣寫:等的是時間,而不是結果
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForTimeout(3000);
// ✅ 正確的做法:等待「結果出現」
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Poster created')).toBeVisible();
今天我們又多認識了 Playwright Test 這個框架本身的幾個重要控制項:
test.describe() 把相關的測試分組,不但讓報告更有層次,也方便對整組套用設定。beforeAll →(beforeEach → 測試 → afterEach)重複 N 次 → afterAll,而且 beforeAll 裡面是拿不到 page 的。waitForTimeout 來等「時間」。下一篇我們會介紹 expect 這個API ,看看 Playwright 提供了哪些強大的斷言方法,以及什麼是所謂的 web-first assertion。